你按下取消鍵,畫面上的轉圈圈圖示卻還在轉。你確定自己呼叫了取消,Log 也印出了取消的訊息,但那個背景工作就是不肯停下來,畫面卡在那裡,好像根本沒聽到你說了什麼。你重新看了一次程式碼,邏輯看起來沒有錯,可是實際跑起來就是不對,你開始懷疑,是不是自己漏了什麼步驟,卻怎麼也想不出漏在哪裡。
這種挫折感很熟悉。你會的是照著範例把協程相關的 API 抄一遍,改幾個變數名稱,讓專案能動起來,卻沒辦法在它壞掉的那一刻,說清楚它為什麼壞掉。抄範例跟理解原理,是完全不同量級的能力,前者讓程式碼能跑,後者讓你在半夜三點被叫起來修 Bug 時,知道問題可能藏在哪裡。
與其直接跳進協程內部的運作機制,這篇文章想先帶你回到問題最初的起點,看看在協程出現之前,我們是怎麼處理這種「需要等待」的工作,以及那種處理方式,到底把我們逼到了什麼樣的角落。
這個系列會有一個貫穿全程的示範情境,一個多來源圖片下載與整合工具。它是一個跨平台皆適用的輕量情境,不綁定 Android、Ktor 或任何特定框架,之後每一天只會取用這個工具的其中一個切片來說明當天的觀念,不是要求你每天回頭把整個專案補完整。
今天要看到的,是這個工具最初、也最陽春的樣子,陽春到幾乎稱不上是一個工具。想像有一個函式,功能單純到只做一件事:輸入一個圖片網址,發出網路請求,等待伺服器回應,拿到圖片資料後回傳出去。就這樣,沒有別的。
問題出在「等待伺服器回應」這幾個字上。在等待的這段期間,呼叫這個函式的執行緒,如果剛好是負責畫面的那條執行緒,會被整個卡住,動彈不得。使用者按下下載按鈕,介面瞬間凍結,按鈕點了沒反應,捲動卡頓,整個畫面像是當機了一樣,直到網路請求回來為止。使用者不會知道背後發生了什麼事,他們只會覺得,這個 App 是不是壞了。
這一天要處理的,就只有這個最陽春的切片,一個來源、一個下載函式、會卡住執行緒。多來源同時下載、平行處理、把多個結果聚合起來,這些複雜度都留到後面幾天,今天先把最單純的問題看清楚。
面對這種會卡住執行緒的情況,工程師早就有對策。最常見的做法,是把下載這件耗時的工作丟到另一條執行緒去處理,讓原本的執行緒可以繼續做別的事,等下載工作真正完成時,再透過一個回呼函式通知呼叫端,告訴它「結果出來了,這是你要的資料」。這個做法確實有效,畫面不再凍結,按鈕點了會有反應,使用者體感上的問題解決了。
如果需求永遠停在「下載一張圖片」,這個做法會相安無事。但真實的需求很少這麼簡單。下載完成後,通常還要接著處理圖片,比如壓縮或裁切;處理完之後,還要更新畫面顯示縮圖;如果過程中發生任何失敗,還得有另一套邏輯去處理錯誤畫面。於是你會看到回呼函式一層包著一層,程式碼開始不斷往右縮排,想理解某一段邏輯,得從最外層的回呼開始,一路往內追蹤好幾層,才能拼湊出完整的執行順序。這種現象有個廣為人知的名字,Callback Hell,它精準地描述了回呼函式或手動 Thread 處理異步流程時,程式碼難以閱讀、難以組合、難以取消的一系列問題。
可讀性變差還不是最根本的麻煩。更根本的問題在於,這種寫法天生難以組合,也難以取消。假設你想把「下載」跟「處理」這兩個異步步驟串接起來,變成一個可以重複使用的流程,你得手動把第一個回呼的結果傳進第二個回呼裡,還要各自處理失敗情況。假設使用者中途按下取消,你得自己設計一個旗標,在每一層回呼裡手動檢查這個旗標,決定要不要繼續往下執行,一旦漏掉某一層忘記檢查,取消就形同虛設。這正是文章開頭那種按下取消鍵、畫面卻還在跑的窘境,最早的原型往往就藏在這種手動拼湊的狀態管理裡,一個旗標沒設對,一個檢查點漏掉了,取消這個動作就悄悄地失效了。
把前面的觀察放在一起看,會發現一件事。執行緒的數量夠不夠多、伺服器回應得夠不夠快,從頭到尾都稱不上是核心癥結。當程式碼需要處理等待、需要把好幾個步驟串接起來時,回呼式寫法會讓程式碼的形狀被「異步」這件事本身扭曲,這才是問題真正的根源。你花在理解程式碼結構、追蹤回呼層層呼叫的心力,遠比花在理解業務邏輯本身的心力還多,這種心智負擔的錯置,才是真正讓人疲憊的地方。
這裡可以借用一個生活化的比喻。傳統的同步等待,像是打電話請人幫忙辦一件事,卻得自己拿著話筒站在電話這頭乾等,對方辦完之前你什麼都做不了,連掛電話去忙下一件事都不行。這正是主執行緒被卡住時的真實寫照。
那麼,理想中的寫法會是什麼樣子。如果有一種方式,能讓程式碼看起來就像是依序寫下去的,由上而下閱讀就能理解執行的先後順序,不需要在腦中反覆跳轉去拼湊回呼的巢狀結構,但實際執行的時候,遇到需要等待的地方又不會真的卡住執行緒,讓其他工作可以照常進行。這樣的寫法,會不會存在。
這裡先只停在這個期待上,不打算深入解釋這件事究竟是怎麼被做到的。今天不會出現任何協程的語法或關鍵字,也還不打算解釋協程具體如何運作,這些機制會留到接下來的內容慢慢展開。今天的任務,只是先把方向指出來。
接下來的 30 天,會用觀念先行、逐步進階的方式往前推進。一開始的幾天,會把「暫停」這件事的底層機制與協程的幾種啟動方式的地基打穩;打穩之後,會建立起結構化並發的核心心智模型,讓你理解協程之間的生命週期是怎麼被管理與收斂的;再往後,會處理協程實際運行的執行環境,以及例外發生時該如何治理;接著會進入 Flow,處理不只回傳一次結果、而是持續產生一連串資料的情境;最後在測試與實戰整合的階段,把前面所有觀念收斂進同一個示範專案裡驗收一次。
今天定案的那個多來源圖片下載與聚合工具,接下來會隨著觀念一天天加深,慢慢長出更多真實情境會遇到的複雜度。你不需要擔心自己錯過某一天就跟不上,每天的內容會盡量保持可以獨立閱讀。
今天從一個會卡住主執行緒的陽春下載函式出發,看見了回呼式寫法確實能解決卡住的問題,卻在可讀性、組合性與取消能力上付出了不小的代價。這些代價的根源出在程式碼的形狀被異步處理本身給扭曲了,執行緒的數量或速度從頭到尾都不是問題所在。
如果我們期待一段程式碼能看起來依序寫下去,遇到等待卻不會卡住主執行緒,那麼一個問題自然浮現,這段程式碼在等待的那個當下,到底發生了什麼事。它是被誰暫停下來的,又是用什麼方式暫停的,暫停之後,它又是怎麼知道該在什麼時候、從哪裡繼續往下執行。
這個疑問,就是《Day 02:第一個 suspend function,暫停到底暫停了什麼》要正式打開的入口。